Выполнил: Киселев Дмитрий
Задача:
В интернет-магазине для дома быта требуется провести аналитику ассортимента и дать рекомендации заказчику по повышению эффективности бизнеса. Заказчик предоставляет данные о транзакциях за определенный период без мастер-данных. Для их анализа потребуется задать верную категоризацию продуктов, определить тренды в покупках и сформулировать и проверить гипотезы, связанные с количеством продаж или выручкой.
План проведения работы:
Выполнить предобработку данных
Провести исследовательский анализ данных
Проанализировать торговый ассортимент;
Сформулировать и проверить статистические гипотезы
Описание данных
Датасет описывает транзакции интернет-магазина товаров для дома и быта.
Колонки в ecommerce_dataset.csv :
date — дата заказа;customer_id — идентификатор покупателя;order_id — идентификатор заказа;product — наименование товара;quantity — количество товара в заказе;price — цена товара.Оглавление
Дашборд
доступен на ресурсе PublicTableau по публичному адресу: https://public.tableau.com/views/product_analysis_project/Dashboard1?:language=en-US&:display_count=n&:origin=viz_share_link
Содержание и элементы управления
Презентация в формате pdf https://disk.yandex.ru/i/U5eRylyLihrH0A
import urllib.request
import pandas as pd
import numpy as np
from datetime import datetime
!pip install pymystem3
import json
from pymystem3 import Mystem
import matplotlib.pyplot as plt
from matplotlib import style
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import colorsys
import seaborn as sns
import squarify
import warnings
warnings.filterwarnings("ignore")
!pip install abc_analysis --user
from abc_analysis import abc_analysis
from scipy import stats as st
from collections import Counter
from collections import OrderedDict
Requirement already satisfied: pymystem3 in c:\users\kslvd\anaconda3\lib\site-packages (0.2.0) Requirement already satisfied: requests in c:\users\kslvd\anaconda3\lib\site-packages (from pymystem3) (2.26.0) Requirement already satisfied: charset-normalizer~=2.0.0 in c:\users\kslvd\anaconda3\lib\site-packages (from requests->pymystem3) (2.0.4) Requirement already satisfied: urllib3<1.27,>=1.21.1 in c:\users\kslvd\anaconda3\lib\site-packages (from requests->pymystem3) (1.26.7) Requirement already satisfied: idna<4,>=2.5 in c:\users\kslvd\anaconda3\lib\site-packages (from requests->pymystem3) (3.2) Requirement already satisfied: certifi>=2017.4.17 in c:\users\kslvd\anaconda3\lib\site-packages (from requests->pymystem3) (2021.10.8) Requirement already satisfied: abc_analysis in c:\users\kslvd\appdata\roaming\python\python39\site-packages (0.1.21) Requirement already satisfied: matplotlib>=2.2.2 in c:\users\kslvd\anaconda3\lib\site-packages (from abc_analysis) (3.4.3) Requirement already satisfied: scipy>=1.1.0 in c:\users\kslvd\anaconda3\lib\site-packages (from abc_analysis) (1.7.1) Requirement already satisfied: numpy>=1.14 in c:\users\kslvd\anaconda3\lib\site-packages (from abc_analysis) (1.22.4) Requirement already satisfied: pandas>=0.22 in c:\users\kslvd\anaconda3\lib\site-packages (from abc_analysis) (1.3.4) Requirement already satisfied: cycler>=0.10 in c:\users\kslvd\anaconda3\lib\site-packages (from matplotlib>=2.2.2->abc_analysis) (0.10.0) Requirement already satisfied: pillow>=6.2.0 in c:\users\kslvd\anaconda3\lib\site-packages (from matplotlib>=2.2.2->abc_analysis) (8.4.0) Requirement already satisfied: pyparsing>=2.2.1 in c:\users\kslvd\anaconda3\lib\site-packages (from matplotlib>=2.2.2->abc_analysis) (3.0.4) Requirement already satisfied: kiwisolver>=1.0.1 in c:\users\kslvd\anaconda3\lib\site-packages (from matplotlib>=2.2.2->abc_analysis) (1.3.1) Requirement already satisfied: python-dateutil>=2.7 in c:\users\kslvd\anaconda3\lib\site-packages (from matplotlib>=2.2.2->abc_analysis) (2.8.2) Requirement already satisfied: six in c:\users\kslvd\anaconda3\lib\site-packages (from cycler>=0.10->matplotlib>=2.2.2->abc_analysis) (1.16.0) Requirement already satisfied: pytz>=2017.3 in c:\users\kslvd\anaconda3\lib\site-packages (from pandas>=0.22->abc_analysis) (2021.3)
try:
df = pd.read_csv(r'C:\Users\kslvd\pyn_projects\yandex_projects\product_analysis\ecommerce_dataset.csv',index_col = False)
except:
print('Не удалось загрузить данные.')
df.sample(10)
| date | customer_id | order_id | product | quantity | price | |
|---|---|---|---|---|---|---|
| 1642 | 2019013022 | b27cb3a0-256a-4c99-acfd-1b68361490f4 | 70036 | Лестница стремянка ЗМИ Конёк медный антик ТС 277 | 1 | 974.0 |
| 2427 | 2019032217 | 9acaf9fa-ed02-46dc-a2f9-237534ace3a0 | 70837 | Корыто оцинкованное 45 л бесшовное, 1303007 | 3 | 749.0 |
| 4480 | 2019060415 | 695cbeda-4295-4b5e-9db9-6ac0adc5f1d7 | 14845 | Однолетнее растение Петуния махровая в кассете... | 20 | 82.0 |
| 2055 | 2019022716 | e9dfd900-8c1b-48dd-8ee6-71c354cb68fc | 70490 | Пеларгония зональная Мультиблум Скарлет Ай d-7... | 1 | 74.0 |
| 1619 | 2019012813 | f9a15108-149f-4b97-9888-2f0dd450cb06 | 69999 | Герань домашняя (пеларгония зональная) цветуща... | 1 | 149.0 |
| 5917 | 2019072115 | 9aa18293-3e06-4eb4-9661-a4e845e6ad6d | 72053 | Штора для ванной комнаты Cannetto 240х200 см к... | 1 | 1199.0 |
| 3127 | 2019042421 | 2a0acc09-e343-4f92-b5c0-f598ef2b07d4 | 14688 | томата (помидор) Моравское чудо №54 сорт индет... | 2 | 38.0 |
| 5515 | 2019062112 | 4396f810-bf7a-4044-8919-837a05dd0853 | 71765 | Чехол для костюма 60х110 см Rozenbal R261016 | 1 | 164.0 |
| 5608 | 2019070100 | fab25c31-09e3-49a6-aa85-4a46ddb1ce14 | 71835 | Штора для ванной Spirella ATLAS TEX белый 120X... | 2 | 1162.0 |
| 3285 | 2019042913 | 603e92a4-fc7f-4203-9ca5-103da5b008ac | 14705 | Лобелия Регатта Regatta микс объем 0,5 л | 2 | 60.0 |
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 6737 entries, 0 to 6736 Data columns (total 6 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 date 6737 non-null int64 1 customer_id 6737 non-null object 2 order_id 6737 non-null int64 3 product 6737 non-null object 4 quantity 6737 non-null int64 5 price 6737 non-null float64 dtypes: float64(1), int64(3), object(2) memory usage: 315.9+ KB
print('Позднейшая дата: {0}; самая ранняя дата: {1}'.format(df.date.max(), df.date.min()))
Позднейшая дата: 2019103116; самая ранняя дата: 2018100100
print('Изначально поле продукты содержит {0} уникальных наименования.'.format(df['product'].nunique()))
Изначально поле продукты содержит 2343 уникальных наименования.
В выгрузке 6737 записи, пропусков нет, транзакции разбиты на уровне позиции в заказе. Временной период 00 часов 1 октября 2018г - 16 часов 31 октября 2019 года, всего 13 полных месяцев,учтем это. Следует задать корректные типы данных в колонках date, order_id. Наименования колонок корректны, для верного избрания категорий придется изучить состав продуктов, похоже преимущественно это растения. Также в таблицу нужно добавить поля выручки (price*quantity), номер часа, номер дня недели, номер месяца и выделить ключевые слова в названии продукта. Перед этим попробуем изучить скрытые дубликаты в номерах заказов или клиентов, аномалии в распределениях или значениях.
df.nunique()
date 2606 customer_id 2451 order_id 2784 product 2343 quantity 49 price 360 dtype: int64
# дата в порядке: год, месяц, число, номер часа в формате "24"
df['date']=pd.to_datetime(df['date'], format="%Y%m%d%H")
df['simple_date']=pd.to_datetime(df['date']).dt.strftime("%Y-%m")
# номер заказа в текст
df['order_id'] = df['order_id'].astype(str)
# создадим новые столбцы, час, номер недели, номер месяца
df['month'] = pd.DatetimeIndex(df['date']).month
df['week'] = pd.DatetimeIndex(df['date']).week
df['hour'] = pd.DatetimeIndex(df['date']).hour
print('В датасете содержится {0} полных дубликатов или {1} часть от выборки. Избавимся от них.'.format(df.duplicated().sum(), df.duplicated().sum()/len(df)))
df.drop_duplicates(inplace=True)
В датасете содержится 0 полных дубликатов или 0.0 часть от выборки. Избавимся от них.
#сначала исследуем логику скрытых дубликатов и аномалий: один день - много разных заказов от одного пользователя
(df
.groupby(['date','customer_id'])
.agg(unique_orders=('order_id', 'nunique'))
.sort_values(by='unique_orders', ascending=False)[:15])
| unique_orders | ||
|---|---|---|
| date | customer_id | |
| 2019-05-30 13:00:00 | e0535076-6270-4df2-8621-cb06264a94fa | 3 |
| 2018-12-10 08:00:00 | c971fb21-d54c-4134-938f-16b62ee86d3b | 3 |
| 2018-12-17 17:00:00 | 86c97bf1-c834-423e-9e38-8acda68f97e8 | 2 |
| 2019-06-24 09:00:00 | 9897ccd6-9441-4886-b709-b06361fabf6c | 2 |
| 2019-03-05 13:00:00 | e3c67587-b75c-417c-ae89-27b248afda6e | 2 |
| 2019-05-13 17:00:00 | dabea2a5-f3e2-4137-b96f-a305dffaf5a8 | 2 |
| 2018-12-04 19:00:00 | b2da4ddc-71b5-499b-9806-d59c7a7e6cf5 | 2 |
| 2019-10-20 20:00:00 | 23ca02ae-37c2-420d-b893-26f9cb262188 | 2 |
| 2018-10-23 11:00:00 | 4d93d3f6-8b24-403b-a74b-f5173e40d7db | 2 |
| 2018-11-17 11:00:00 | 3414d523-5337-49b9-84f5-dd151a0b5077 | 2 |
| 2019-04-22 10:00:00 | d62fee07-7017-4fbf-a2f4-e951340f129d | 2 |
| 2018-12-14 14:00:00 | c971fb21-d54c-4134-938f-16b62ee86d3b | 2 |
| 2019-10-06 18:00:00 | 2e1f5da1-212b-49d7-a62e-a407543905ca | 2 |
| 2019-01-23 12:00:00 | e163be92-04cb-4859-a0f5-143ed106da73 | 2 |
| 2019-04-30 19:00:00 | 877ea53d-c6c8-479c-959b-22bdeba2a743 | 2 |
# теперь исследуем логику повторных записей заказов
(df
.groupby(['order_id','product'])
.agg(count=('customer_id', 'count'),unique_customers=('customer_id', 'nunique'),qty=('quantity', 'sum'))
.sort_values(by='count', ascending=False)[:15])
| count | unique_customers | qty | ||
|---|---|---|---|---|
| order_id | product | |||
| 14833 | Рассада Огурец Кристина ячейки по 6 шт Е6 | 74 | 1 | 75 |
| Рассада Кабачка сорт Аэронавт, кассета по 6шт | 74 | 1 | 75 | |
| Рассада Кабачка сорт Горный, кассета по 6шт | 74 | 1 | 75 | |
| Рассада Кабачка сорт Зебра кассета 6 шт E6 | 74 | 1 | 75 | |
| Рассада Кабачка сорт Сосновский, кассета по 6шт | 74 | 1 | 75 | |
| Рассада Кабачка сорт Золотинка кассета 6 шт E6 | 74 | 1 | 75 | |
| Рассада Патиссона сорт Диск, кассета по 6шт | 74 | 1 | 75 | |
| Рассада Патиссона сорт НЛО Белый, кассета по 6шт | 74 | 1 | 75 | |
| Рассада Кабачка сорт Белогор кассета 6 шт E6 | 74 | 1 | 75 | |
| Тагетис крупноцветковый рассада однолетних цветов в кассете по 10 шт | 74 | 1 | 75 | |
| Рассада Огурца сорт Каролина кассета 6 шт E6 | 74 | 1 | 75 | |
| Рассада Огурец Фараон ячейки по 6 шт Е6 | 74 | 1 | 75 | |
| 70960 | Сумка-тележка 2-х колесная Gimi Argo красная | 60 | 1 | 60 |
| 69162 | Термометр на окно уличный на липучках Т-5 h-20 см 1210035 | 23 | 1 | 46 |
| 71148 | Гладильная доска НИКА Гранд 122х42 см НГ | 16 | 1 | 18 |
Существуют пользователи которые генерируют много разных заказов за одни сутки, но судя по статистике это скорее лояльные покупатели. Имеется проблема дублирования записей заказов, это может быть баг записи данных, самого сервиса или отсутствующие параметры статуса заказа из выгрузки. Видно, что в худшем случае 1 заказ, состоящий из 12 позиций единственного уникального пользователя повторяется 74 раза. Удалим дубликаты с повторными заказами у одних и тех же пользователей по одним и тем же продуктам. Оставим самые поздние записи по дате - такие заказы будут считаться верными и исполнеными.
df=df.sort_values(by='date', ascending=True)
df=df.drop_duplicates(subset=['order_id','customer_id','product'], keep='last', ignore_index=False)\
.reset_index().drop('index', axis=1);
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 4851 entries, 0 to 4850 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 date 4851 non-null datetime64[ns] 1 customer_id 4851 non-null object 2 order_id 4851 non-null object 3 product 4851 non-null object 4 quantity 4851 non-null int64 5 price 4851 non-null float64 6 simple_date 4851 non-null object 7 month 4851 non-null int64 8 week 4851 non-null int64 9 hour 4851 non-null int64 dtypes: datetime64[ns](1), float64(1), int64(4), object(4) memory usage: 379.1+ KB
Это довольно существенный изъян выгрузки - реальное количество записей сокращено на 1886 (~ -28%), однако он имеет техническую природу. Поэтому дальнейшую фильтрацию будем замерять к числу записей - 4851.
# уточним состав наших данных, сделать это наглядно нам помогут простые гистограммы
df.hist(figsize=(15, 20));
Даже с "двумя октябрями" в данных преобладают записи с апреля по май - выражена сильная пиковая сезонность. Количество преимущественно до двухсот. Цены в основном до 3000. Просадка в распредлении записей по датам - август, сентябрь. В течении дня покупки совершаются неравномерно: примерно в 1.8 раза чаще (+80%) с 7.00 до 15.00 чем с 15.00 до 00.00
df['quantity'].describe()
count 4851.000000 mean 2.835910 std 17.642155 min 1.000000 25% 1.000000 50% 1.000000 75% 1.000000 max 1000.000000 Name: quantity, dtype: float64
df['price'].describe()
count 4851.000000 mean 516.092971 std 946.085142 min 9.000000 25% 90.000000 50% 150.000000 75% 502.000000 max 14917.000000 Name: price, dtype: float64
# создадим столбец с выручкой
df['income'] = df['quantity']*df['price']
df['income'].describe()
count 4851.000000 mean 852.206555 std 9829.440953 min 9.000000 25% 120.000000 50% 194.000000 75% 734.000000 max 675000.000000 Name: income, dtype: float64
Среднее значение количества для позиции 2,8; изучим аномальные значения за пределами трех стандартных отклонений. Аналогично, для цены среднее значение - 516, аномальные значений за пределами трех сигм (вероятность 99,72%) также ярко выражено. Изучим записи с quantity > 86 и price > 6846. Но нам пока неизвестно как эти дорогие и многочисленные покупки влияют на общий доход. Дело в том, что если бизнес невелик, а продажи относительно нестабильны и случаются нетипичные крупные заказы или редкие дорогие заказы, это может быть существенно для бизнеса. Замерим долю выручки каждой аномалии относительно общей выручки.
#значения переменных попадающих за пределы 3 сигм
sigma3_quantity=np.percentile(df['quantity'], [99.72][0])
sigma3_price=np.percentile(df['price'], [99.72][0] )
display(sigma3_quantity,sigma3_price)
86.72000000000116
6846.800000000007
#замеряем доходы по группам
total_income=df.income.sum()
impact_quantity = df.query('quantity > @sigma3_quantity').income.sum()
impact_price = df.query('price > @sigma3_price').income.sum()
print('Доля от выручки аномальных заказов по количеству - {0}; по цене - {1}.'.format(round(impact_quantity/total_income,3), round(impact_price/total_income,3)))
Доля от выручки аномальных заказов по количеству - 0.191; по цене - 0.028.
Для аномалий в количестве эффект крайне велик - кусок в 19,1% выручки. Взглянем на такие продукты и заказы поближе, кроме этого будет полезно зафиксировать группу многочисленных товаров отдельно - они могут выделяться логистическими особенностями, наверняка размером, и иметь большое влияние на затраты. Зафиксируем списки продуктов для предстоящей категоризации, они могут пригодиться (в меньшей степени по ценам в данном случае, эффект ~ 3% на выручку).
#Уникальные покупатели товара в больших количествах
df.query('quantity > @sigma3_quantity').customer_id.nunique()
13
df.query('quantity > @sigma3_quantity')
| date | customer_id | order_id | product | quantity | price | simple_date | month | week | hour | income | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 13 | 2018-10-01 15:00:00 | 0948b0c2-990b-4a11-b835-69ac4714b21d | 68486 | Крючок одежный 2-х рожковый серый металлик с п... | 96 | 38.0 | 2018-10 | 10 | 40 | 15 | 3648.0 |
| 241 | 2018-10-16 08:00:00 | cd09ea73-d9ce-48c3-b4c5-018113735e80 | 68611 | Крепеж для пружины дверной, 1107055 | 150 | 19.0 | 2018-10 | 10 | 42 | 8 | 2850.0 |
| 242 | 2018-10-16 08:00:00 | cd09ea73-d9ce-48c3-b4c5-018113735e80 | 68611 | Пружина дверная 240 мм оцинкованная (Д-19 мм) ... | 150 | 38.0 | 2018-10 | 10 | 42 | 8 | 5700.0 |
| 481 | 2018-11-01 08:00:00 | aa42dc38-780f-4b50-9a65-83b6fa64e766 | 68815 | Муляж ЯБЛОКО 9 см красное | 170 | 51.0 | 2018-11 | 11 | 44 | 8 | 8670.0 |
| 499 | 2018-11-02 11:00:00 | 0c5aaa88-e346-4f87-8f7a-ad8cbc04e965 | 68831 | Муляж ЯБЛОКО 9 см красное | 140 | 59.0 | 2018-11 | 11 | 44 | 11 | 8260.0 |
| 966 | 2018-12-04 17:00:00 | 7d255526-fcc2-4f79-b28a-217d7d2373a8 | 69206 | Щетка для посуды *ОЛЯ*, Мультипласт 1807010 | 100 | 26.0 | 2018-12 | 12 | 49 | 17 | 2600.0 |
| 1008 | 2018-12-10 14:00:00 | a984c5b7-ff7e-4647-b84e-ef0b85a2762d | 69289 | Ручка-скоба РС-100 белая *Трибатрон*, 1108035 | 200 | 29.0 | 2018-12 | 12 | 50 | 14 | 5800.0 |
| 1324 | 2019-01-21 09:00:00 | 8eabcaca-e8c8-4eee-9079-4ff5f612273a | 69893 | Щетка для мытья посуды КОЛИБРИ М5202 большая | 100 | 34.0 | 2019-01 | 1 | 4 | 9 | 3400.0 |
| 2071 | 2019-03-23 10:00:00 | 685d3d84-aebb-485b-8e59-344b3df8b3d3 | 70841 | Плечики пластмассовые Размер 52 - 54 Тула 1205158 | 150 | 20.0 | 2019-03 | 3 | 12 | 10 | 3000.0 |
| 3373 | 2019-05-20 21:00:00 | 5d189e88-d4d6-4eac-ab43-fa65a3c4d106 | 71478 | Муляж ЯБЛОКО 9 см красное | 300 | 51.0 | 2019-05 | 5 | 21 | 21 | 15300.0 |
| 3738 | 2019-06-11 07:00:00 | 146cd9bf-a95c-4afb-915b-5f6684b17444 | 71668 | Вешалки мягкие для деликатных вещей 3 шт шоколад | 334 | 148.0 | 2019-06 | 6 | 24 | 7 | 49432.0 |
| 3805 | 2019-06-18 15:00:00 | 312e9a3e-5fca-43ff-a6a1-892d2b2d5ba6 | 71743 | Вантуз с деревянной ручкой d14 см красный, Bur... | 1000 | 675.0 | 2019-06 | 6 | 25 | 15 | 675000.0 |
| 4677 | 2019-10-07 11:00:00 | d933280e-5372-448f-be44-b269c8bafc2a | 72885 | Крепеж для пружины дверной оцинкованный, 1107054 | 100 | 19.0 | 2019-10 | 10 | 41 | 11 | 1900.0 |
| 4824 | 2019-10-28 10:00:00 | 018fb729-3525-4314-8e4d-1982b1062f9f | 73110 | Шпингалет 80 мм белый с пружиной, 1102188 | 100 | 44.0 | 2019-10 | 10 | 44 | 10 | 4400.0 |
#Уникальные покупатели товара с высокими ценами по текущему ассортименту
df.query('price > @sigma3_price').customer_id.nunique()
14
df.query('price > @sigma3_price')
| date | customer_id | order_id | product | quantity | price | simple_date | month | week | hour | income | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 555 | 2018-11-05 22:00:00 | fc333417-1d11-4a2a-b946-0957ae0603e9 | 68872 | Сумка-тележка хозяйственная Rolser MOU041 malv... | 1 | 7597.0 | 2018-11 | 11 | 45 | 22 | 7597.0 |
| 1080 | 2018-12-17 17:00:00 | 86c97bf1-c834-423e-9e38-8acda68f97e8 | 69417 | Гладильная доска Leifheit AirBoard Compact M P... | 1 | 7424.0 | 2018-12 | 12 | 51 | 17 | 7424.0 |
| 1693 | 2019-02-24 10:00:00 | ac250053-a236-467a-97d2-ddbb9bf4a1ba | 70423 | Сумка-тележка хозяйственная Andersen Alu Star ... | 1 | 8437.0 | 2019-02 | 2 | 8 | 10 | 8437.0 |
| 1991 | 2019-03-14 18:00:00 | 27d4ce42-352b-4756-bf5b-8724e05d5131 | 70740 | Сумка-тележка хозяйственная Rolser IMX006 bass... | 1 | 7679.0 | 2019-03 | 3 | 11 | 18 | 7679.0 |
| 2299 | 2019-04-05 19:00:00 | c0c60544-3a99-49d0-8a8e-cf7f293c22cb | 71035 | Сумка-тележка хозяйственная Andersen Royal Sho... | 1 | 8737.0 | 2019-04 | 4 | 14 | 19 | 8737.0 |
| 2528 | 2019-04-20 08:00:00 | 16838b36-762a-474d-bda4-bad95f8d7813 | 71209 | Автоматическая щетка Leifheit для мытья окон с... | 1 | 7229.0 | 2019-04 | 4 | 16 | 8 | 7229.0 |
| 2545 | 2019-04-21 16:00:00 | 19d904d8-8d16-476d-8f66-b2a3b7a23660 | 71227 | Сумка-тележка хозяйственная Rolser MNB019 rojo... | 1 | 8077.0 | 2019-04 | 4 | 16 | 16 | 8077.0 |
| 3740 | 2019-06-11 10:00:00 | 85716494-dd51-4d1d-ab28-8c74b87f14dc | 71674 | Сушилка для белья Leifheit Pegasus 200 DeLuxe ... | 1 | 7004.0 | 2019-06 | 6 | 24 | 10 | 7004.0 |
| 4193 | 2019-07-29 17:00:00 | 0d87f4ae-465a-4fac-81e6-5d629761783e | 72139 | Сушилка уличная Leifheit 85210 LINOMATIC V 400... | 1 | 14917.0 | 2019-07 | 7 | 31 | 17 | 14917.0 |
| 4195 | 2019-07-30 01:00:00 | e2fe6b48-1955-4882-925c-733f545df026 | 72140 | Сумка-тележка хозяйственная Rolser MNB017 fucs... | 1 | 7679.0 | 2019-07 | 7 | 31 | 1 | 7679.0 |
| 4203 | 2019-07-31 07:00:00 | 2d51b277-cb8a-48eb-bdbf-b3315276f01a | 72153 | Сумка-тележка хозяйственная Andersen Scala Sho... | 1 | 7199.0 | 2019-07 | 7 | 31 | 7 | 7199.0 |
| 4358 | 2019-08-23 12:00:00 | 8d85f320-5f55-4647-814c-da1a6b21c2a4 | 72381 | Урна уличная стальная Сальвадор 02.021.0 46х11... | 1 | 7349.0 | 2019-08 | 8 | 34 | 12 | 7349.0 |
| 4761 | 2019-10-16 15:00:00 | d5584388-ffbe-42fd-a746-a98828ec919f | 72992 | Стремянка 7 ступенчатая Hailo 8040-707 XXL 13 ... | 1 | 7724.0 | 2019-10 | 10 | 42 | 15 | 7724.0 |
| 4809 | 2019-10-23 15:00:00 | 6cfdad48-10fd-443b-bc67-93078c646779 | 73077 | Урна уличная стальная Юпитер 02.012.0 38х38х60 см | 1 | 6899.0 | 2019-10 | 10 | 43 | 15 | 6899.0 |
Показательно что это всего 13-14 покупателей из 2451 уникальных. Для небольшого бизнеса возможность ухватиться за платящих клиентов и расширить ассортимент этих категорий может иметь высокую эффективность. Или например выйти на оптовые объемы и клиентов B2B. Пока запомним множество наименований (rare_goodsIHI - rare goods IncomeHighlyImpacted) для (quantity > @sigma3_quantity) как потенциальные точки роста магазина с долей 19.1% в выручке.
rare_goodsIHI=df.query('quantity > @sigma3_quantity')
display(rare_goodsIHI['product'].unique())
array(['Крючок одежный 2-х рожковый серый металлик с полимерным покрытием *Тонар*, 1110027',
'Крепеж для пружины дверной, 1107055',
'Пружина дверная 240 мм оцинкованная (Д-19 мм) без крепления, 1107014',
'Муляж ЯБЛОКО 9 см красное',
'Щетка для посуды *ОЛЯ*, Мультипласт 1807010',
'Ручка-скоба РС-100 белая *Трибатрон*, 1108035',
'Щетка для мытья посуды КОЛИБРИ М5202 большая',
'Плечики пластмассовые Размер 52 - 54 Тула 1205158',
'Вешалки мягкие для деликатных вещей 3 шт шоколад',
'Вантуз с деревянной ручкой d14 см красный, Burstenmann, 0522/0000',
'Крепеж для пружины дверной оцинкованный, 1107054',
'Шпингалет 80 мм белый с пружиной, 1102188'], dtype=object)
Несмотря на большой эффект в выручке, данные записи будут иметь негативный эффект на предстоящий статистический анализ. Их количество совсем невелико (14 записей) и теперь мы вернемся к ним отдельно в блоке выводов. Заменим рабочий датасет df и далее продолжим анализ.
# удаляем аномалии по количеству
df.query('quantity < @sigma3_quantity', inplace=True)
# создаем ключи для будущих категорий в исходном датафрейме
# используем метод str.split на названиях продуктов, чтобы разбить текст по пробелам и взять первые два слова. Если в слове дефис, то первое.
df['key_word1']=df['product'].str.split(' ').str.get(0)
df['key_word1']=df['key_word1'].str.split('-').str.get(0)
df['key_word2']=df['product'].str.split(' ').str.get(1)
df['key_word2']=df['key_word2'].str.split('-').str.get(0)
# посмотрим на ассортимент по ключам совместно, отдельно и насколько товар популярен, какова его доля от общего числа записей
product_rating = df.groupby(['key_word1','key_word2']).agg(count_name=('product', 'count'))\
.sort_values(by='count_name', ascending=False)[:46].reset_index()
product_rating['count_cumulated'] = product_rating['count_name'].cumsum()
product_rating['share_total'] = round(product_rating['count_cumulated']/len(df),2)
display(product_rating)
| key_word1 | key_word2 | count_name | count_cumulated | share_total | |
|---|---|---|---|---|---|
| 0 | Пеларгония | зональная | 375 | 375 | 0.08 |
| 1 | Сушилка | для | 272 | 647 | 0.13 |
| 2 | Пеларгония | розебудная | 249 | 896 | 0.19 |
| 3 | Рассада | зелени | 220 | 1116 | 0.23 |
| 4 | томата | (помидор) | 142 | 1258 | 0.26 |
| 5 | Гладильная | доска | 118 | 1376 | 0.28 |
| 6 | Сумка | хозяйственная | 116 | 1492 | 0.31 |
| 7 | Герань | домашняя | 110 | 1602 | 0.33 |
| 8 | Сумка | 2 | 93 | 1695 | 0.35 |
| 9 | Тележка | багажная | 85 | 1780 | 0.37 |
| 10 | Чехол | для | 79 | 1859 | 0.38 |
| 11 | Однолетнее | растение | 67 | 1926 | 0.40 |
| 12 | Таз | пластмассовый | 67 | 1993 | 0.41 |
| 13 | Штора | для | 64 | 2057 | 0.43 |
| 14 | Рассада | Капусты | 57 | 2114 | 0.44 |
| 15 | Искусственный | цветок | 51 | 2165 | 0.45 |
| 16 | Коврик | придверный | 44 | 2209 | 0.46 |
| 17 | Рассада | Арбуза | 40 | 2249 | 0.46 |
| 18 | Роза | садовая | 39 | 2288 | 0.47 |
| 19 | Пеларгония | Toscana | 37 | 2325 | 0.48 |
| 20 | Бакопа | ампельная | 36 | 2361 | 0.49 |
| 21 | Цветок | искусственный | 35 | 2396 | 0.50 |
| 22 | Петуния | махровая | 35 | 2431 | 0.50 |
| 23 | Рассада | Огурца | 26 | 2457 | 0.51 |
| 24 | Корзина | для | 26 | 2483 | 0.51 |
| 25 | Рассада | Кабачка | 24 | 2507 | 0.52 |
| 26 | Рассада | Дыни | 22 | 2529 | 0.52 |
| 27 | Полки | QWERTY | 21 | 2550 | 0.53 |
| 28 | Подвесное | кашпо | 21 | 2571 | 0.53 |
| 29 | Петуния | Surfinia | 21 | 2592 | 0.54 |
| 30 | Рассада | томата | 20 | 2612 | 0.54 |
| 31 | Рассада | Тыквы | 16 | 2628 | 0.54 |
| 32 | Декабрист | в | 16 | 2644 | 0.55 |
| 33 | Калибрахоа | Aloha | 16 | 2660 | 0.55 |
| 34 | Тарелка | обеденная | 15 | 2675 | 0.55 |
| 35 | Петуния | простая | 15 | 2690 | 0.56 |
| 36 | Салатник | Luminarc | 15 | 2705 | 0.56 |
| 37 | Пеларгония | тюльпановидная | 14 | 2719 | 0.56 |
| 38 | Мята | перечная | 14 | 2733 | 0.57 |
| 39 | Рассада | пряной | 14 | 2747 | 0.57 |
| 40 | Тарелка | десертная | 14 | 2761 | 0.57 |
| 41 | Флокс | метельчатый | 14 | 2775 | 0.57 |
| 42 | Пеларгония | розоцветная | 13 | 2788 | 0.58 |
| 43 | Карниз | для | 13 | 2801 | 0.58 |
| 44 | Тележка | хозяйственная | 13 | 2814 | 0.58 |
| 45 | Подкладка | под | 13 | 2827 | 0.58 |
Ассортимент достаточно пестрый, попробуем задать уникальную категоризацию с учетом популярности у покупателей.
df.info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 4837 entries, 0 to 4850 Data columns (total 13 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 date 4837 non-null datetime64[ns] 1 customer_id 4837 non-null object 2 order_id 4837 non-null object 3 product 4837 non-null object 4 quantity 4837 non-null int64 5 price 4837 non-null float64 6 simple_date 4837 non-null object 7 month 4837 non-null int64 8 week 4837 non-null int64 9 hour 4837 non-null int64 10 income 4837 non-null float64 11 key_word1 4837 non-null object 12 key_word2 4837 non-null object dtypes: datetime64[ns](1), float64(2), int64(4), object(6) memory usage: 529.0+ KB
Метод полного соответствия словарю через парсинг интернет каталогов на сайтах затруднен из-за неоднородности состава наименований у нашего магазина. Устройство современных сайтов - торговых площадок затрудняют сбор данных особенно по множественным категориям, а некоторые намеренно его ограничивают. Принято решение создать специальный алгоритм с использованием библиотеки для лематизации текста, основываясь на двух ключах в полном наименовании товара. Он должен помочь установить объекты из текста.
Будем использовать pymystem3 (A Python wrapper of the Yandex Mystem 3.1 morphological analyzer). Библиотека позволит распознать части речи и лемматизировать слова (привести к изначальной форме, пример "шёл"-"идти"). Так мы получим возможность решить задачу категоризации неупорядоченных данных (для нас - названия продукта) через минимизацию количества объектов в исходных названиях и установлении их популярности в датасете.
Можно принять, что на любом языке объектом данных/информации выступают слова, которые группируются по частям речи. Имена существительные часто сами объекты, а прилагательные - их характеристики. Тогда для задачи категоризации множества наименований будет уместно использовать на них упорядоченный фильтр в логике: лучше всего объект описывает имя существительное, остальные части речи - уточняющие атрибуты объекта. Получается, лучшие комбинации частей речи 2ух ключей названия товара в русском языке это сущ. + сущ.; сущ. + прил.; прил. + сущ.. Остальные сочетания имееют более низкую ценность для определения общих объектов в названиях.
Реализуем задумку с помощью функции, которая примет наш датасет с заготовленными ключами наименования продуктов key_word1 и key_word2
def Get_slovarb(df):
"""Эта функция создает словари с дополнительными атрибутами по заранее выделенным ключам.
На входе она получает датафрейм, где выделены ключи наименований товара - столбцы с именами key_word1, key_word2.
Ключи - столбцы переводятся в текстовые строки, анализируются с использованием библиотеки pymystem3.
На выходе создаются 2 словаря к исходным уникальным ключам в формате датафрейма - их содержимое можно включать для работы с основным датасетом df.
"""
#принимаем датафрейм в аргумент и переводим в строки два ранее выделеных ключа в названиях товаров
key_str1 = ','.join(list(df.key_word1.unique()))
key_str2 = ','.join(list(df.key_word2.unique()))
#вызываем внешнюю функцию и метод библиотеки pymystem3 - она работает с текстом и в т. ч. может определить часть речи у слова
#результаты запишем для каждой строки-ключа
m = Mystem()
result1=m.analyze(key_str1)
result2=m.analyze(key_str2)
#теперь отфильтруем результаты работы метода для каждой строки-ключа в словарь
extras1=[]
extras2=[]
for item in result1:
try:
element=item.get('analysis')[0]
element['key']=item.get('text')
extras1.append(element)
except:
pass
for item in result2:
try:
element=item.get('analysis')[0]
element['key']=item.get('text')
extras2.append(element)
except:
pass
# результатом функции становятся датафреймы-словари для строк-ключей slovarb1 и slovarb2
# расшифруем абревиатуры частей речи из библиотеки pymystem3 и избавимся от лишних данных и дубликатов в словарях
slovarb1=pd.DataFrame.from_dict(pd.json_normalize(extras1), orient='columns')
slovarb2=pd.DataFrame.from_dict(pd.json_normalize(extras2), orient='columns')
atributs={'S': 'сущ.',
'A': 'прил.',
'NUM': 'числ.',
'A-NUM': 'числ.-прил.',
'V': 'глаг.',
'ADV': 'нареч.',
'PRAEDIC': 'предикатив.',
'PARENTH': 'вводное.',
'S-PRO': 'местоим. сущ.',
'A-PRO': 'местоим. прил.',
'ADV-PRO': 'местоим. нареч.',
'PRAEDIC-PRO': 'местоим. предик.',
'PR': 'предлог.',
'CONJ': 'союз.',
'PART': 'частица.',
'INTJ': 'межд.'}
for item in atributs:
slovarb1['gr'] = slovarb1['gr'].str.replace(item,atributs[item])
slovarb2['gr'] = slovarb2['gr'].str.replace(item,atributs[item])
slovarb1['word_class']=slovarb1['gr'].str.split('.').str.get(0)
slovarb2['word_class']=slovarb2['gr'].str.split('.').str.get(0)
slovarb1.drop_duplicates(inplace=True)
slovarb2.drop_duplicates(inplace=True)
# дополнительно удлаим дубликаты для слов-ключей которые имеют происхождение в двух и более частях речи, оставим только сущ.
for each in [slovarb1,slovarb2]:
each['sort_index'] = each['word_class']=='сущ'
each.sort_values(by='sort_index',ascending=False, inplace=True)
each.drop_duplicates(subset='key', keep='first', inplace=True)
each.drop(['gr','qual','lex','wt','sort_index'], axis=1, inplace=True)
return slovarb1, slovarb2
#сформируем словари и изучим состав наших продуктов по двум словам-ключам
slovarb1,slovarb2=Get_slovarb(df)
#состав частей речи для первого словаря
slovarb1.groupby(['word_class']).agg(count_class=('key', 'count')).sort_values(by='count_class', ascending=False)
| count_class | |
|---|---|
| word_class | |
| сущ | 381 |
| прил | 55 |
| глаг | 4 |
| COM= | 1 |
#состав частей речи для второго словаря
slovarb2.groupby(['word_class']).agg(count_class=('key', 'count')).sort_values(by='count_class', ascending=False)
| count_class | |
|---|---|
| word_class | |
| сущ | 269 |
| прил | 240 |
| предлог | 11 |
| глаг | 10 |
| COM= | 1 |
| межд | 1 |
| союз | 1 |
Типично для русского языка - на первом месте больше всего существительных (381 слово из 460), далее - описательная часть(либо тоже существительное, с близкой вероятностью прилагательное. Теперь можно собрать категории - для этого сначала определим объекты наименований со всеми уникальными комбинацииями сущ. в обоих ключах. Объединение в категории произведем по популярности и доле выручки объектов.
Для удобства обернем слияние словарей, обработку и сортировку датафрейма в функцию, которая принимала бы датасет и исходные словари, а на выходе отдавала датафрейм с готовыми категориями.
В нашем датасете множество нарицательных наименований растений, обогатим данные наших словарей названиями из открытого доступа в справочнике растений сайта Global Flowers. ( https://global.flowers/plants/?title-index=local ). Это позволит избежать временных затрат на парсинг сайта, который в разы больше чем простое копирование целого алфавитного справочника (в локальном файле они собраны в txt одной строкой, выложены на yadisk - https://disk.yandex.ru/d/WtTBRKXYobec0w ). Совпадение названий цветков или слово "растение" в названии должно объявлять категорию "растения" и так далее по коду ниже.
try:
with open(r'C:\Users\kslvd\pyn_projects\yandex_projects\product_analysis\flowerss.txt' ,'r', encoding='UTF-8') as f:
flowers = f.read()
except:
for line in urllib.request.urlopen("https://docs.google.com/uc?export=download&id=1xO894AlH20mvPgq7Ws0HlReymg_jIkiK"):
flowers=line.decode('UTF-8')
flowers=flowers.lower()
def сategorize_df (df, slovar1, slovar2, flowers):
"""Эта функция определяет объекты в названиях по ключевым словам и категоризирует их с помощью словарей
Принимает начальный датафрейм (df), заготовленные словари (slovar1, slovar2) и строчку нарицательных имен растений flowers - str
"""
#добавляем к датафрейму "слева" части речи, включаем проверку длины df
print('Длина начального датафрейма: {0}.'.format( len(df) ))
df_merged=pd.merge(
df,
slovarb1,
how="left",
left_on='key_word1',
right_on='key',
sort=False,
copy=False,
indicator=False,
validate=None,
)
df_merged=pd.merge(
df_merged,
slovarb2,
how="left",
left_on='key_word2',
right_on='key',
sort=False,
copy=False,
indicator=False,
suffixes=('1', '2'),
validate=None,
)
print('Длина обрабатываемого датафрейма со словарями: {0}.'.format(len(df_merged)))
df_merged['word_class1'].fillna('Unknown', inplace=True)
df_merged['word_class2'].fillna('Unknown', inplace=True)
# ниже локальная техническая функция, лепит "объект" из двух ключевых слов, ориентируясь на существительные имена
def pre_categorize(row):
word_class1 = row['word_class1']
word_class2 = row['word_class2']
key_word1 = row['key_word1']
key_word2 = row['key_word2']
if (word_class1 == 'сущ' and word_class2 == 'сущ'):
cat = key_word1 + " " + key_word2
return cat
if (word_class1 == 'сущ' and word_class2 != 'сущ'):
cat = key_word1
return cat
if (word_class1 != 'сущ' and word_class2 == 'сущ'):
cat = key_word2
return cat
return 'not defined'
# применяем функцию чтобы выделить объекты-товары из ключевых слов их названия:
df_merged['object'] = df_merged.apply(pre_categorize, axis=1)
df_merged['object'] = df_merged['object'].str.lower().replace('ё', 'е')
print('Из {0} разных наименований получено {1} объектов.'.format(len(df_merged['product'].unique()),len(df_merged['object'].unique())))
#добавляет файлы из строки с цветами, производим категоризацию популярных объектов:
def add_values(row, values=flowers):
obj = row['object']
product = row['product']
if 'Сумка-тележка' in product or 'тележка' in obj:
return 'сумки/тележки'
if 'сушилка' in obj:
return 'сушилки'
if 'муляж' in obj:
return 'муляжи'
if obj in values or 'растение' in product:
return 'растения'
if 'рассада' in obj or 'томата' in obj:
return 'рассада'
if obj in 'доска коврик чехол штора таз тарелка корзина вешалка стремянка полки простынь скатерть урна контейнер набор ножей салфетка набор вешалок одеяло щетка кашпо веник сорго швабра стремянки ведро чайник наматрацник':
return 'бытовые товары'
return 'прочее'
# применяем функцию категоризации:
df_merged['category'] = df_merged.apply(add_values, axis=1)
print('Из всех объектов для {0} не удалось определить категорию.'.format(df_merged.query('category == False').object.nunique()))
#построим сводную таблицу для наглядности исполнения функции, выведем на экран
summary = df_merged.query('category != False').groupby(['category']).agg(count=('product', 'count'), qty=('quantity', 'sum'),income=('income', 'sum'))\
.sort_values(by='income', ascending=False)[:20]
summary['count_cumulated'] = summary['count'].cumsum()
summary['share_cumulated'] = round(summary['count_cumulated']/len(df_merged),2)
summary['income_cumulated'] = summary['income'].cumsum()
summary['share_of tincome'] = round(summary['income_cumulated']/df_merged.income.sum(),2)
summary['tincome_sharesize'] = summary['share_of tincome'] - summary['share_of tincome'].shift(1, axis = 0)
df_merged.drop(['key_word1','key_word2','key1','key2','word_class1','word_class2'], axis=1, inplace=True)
df_merged=df_merged.reset_index()
print('Summary по категориям:')
display(summary)
return df_merged
new=сategorize_df(df, slovar1=slovarb1, slovar2=slovarb2, flowers=flowers)
Длина начального датафрейма: 4837. Длина обрабатываемого датафрейма со словарями: 4837. Из 2335 разных наименований получено 566 объектов. Из всех объектов для 0 не удалось определить категорию. Summary по категориям:
| count | qty | income | count_cumulated | share_cumulated | income_cumulated | share_of tincome | tincome_sharesize | |
|---|---|---|---|---|---|---|---|---|
| category | ||||||||
| бытовые товары | 833 | 2643 | 1255455.0 | 833 | 0.17 | 1255455.0 | 0.38 | NaN |
| сумки/тележки | 333 | 393 | 710689.0 | 1166 | 0.24 | 1966144.0 | 0.59 | 0.21 |
| прочее | 999 | 2428 | 575110.0 | 2165 | 0.45 | 2541254.0 | 0.76 | 0.17 |
| растения | 1556 | 2869 | 342168.0 | 3721 | 0.77 | 2883422.0 | 0.86 | 0.10 |
| сушилки | 282 | 308 | 318431.0 | 4003 | 0.83 | 3201853.0 | 0.96 | 0.10 |
| рассада | 661 | 994 | 86588.0 | 4664 | 0.96 | 3288441.0 | 0.98 | 0.02 |
| муляжи | 173 | 1032 | 55653.0 | 4837 | 1.00 | 3344094.0 | 1.00 | 0.02 |
Получилось 7 категорий, самая крупная по доле выручки - "бытовые товары", самая многочисленная по заказам - "растения". Достаточно большие и явно выделяющиеся от остальных - "сумки/тележки", "сушилки". Самые маленькие по выручке, но также с большим количеством товара - "рассада" и "муляжи". В категорию "прочие" попали все редкие по продажам товары с долей в совокупной выручке < 17%
pivot_dt=new.groupby(['category']).agg(count=('product', 'count'), qty=('quantity', 'sum'),income=('income', 'sum'))\
.sort_values(by='income', ascending=False)
a=pivot_dt.reset_index().income.values.tolist()
b=pivot_dt.index.get_level_values(0).tolist()
plt.style.use('seaborn-poster')
squarify.plot(sizes=a,label=b, alpha=.8 )
plt.axis('off')
plt.show()
Выше на графике представлены категории в размерах относительно принесенной выручки за весь период. Теперь перейдем к динамике и качественному анализу категорий.
Проведем abc анализ датасета в группировке по изначальному наименованию и критерию доходности income. Так как у нас нет данных о себестоимости, нет смысла множить критерии дополнительными признаками (для двух получится уже 9 категорий). И даже оценивая доходную часть, минимизируя тем самым риск ошибки ложных выводов в условиях ограниченных данных, abc анализ не способен проигнорировать ошибки рода: товара нет на складе, временный ассортимент, промо и тд. Нам понадобится простая и удобная библиотека abc_analysis - она исключает рутинную работу с табличками при использовании метода анализа и адаптирована к работе с pandas. Границы % можно вывести на график, они вычисляются статистически.
Принимая допущения, по итогу попробуем выделить наглядно очаги "выгодных товаров" в разбивке категорий, заданных ранее. Помним, что редкие товары с высоким влиянием на доход изначально изолированы в отдельный датасет (rare_goodsIHI - rare goods IncomeHighlyImpacted). Разделение позволяет оценивать актуальный ассортимент, избегая влияния на среднюю выручку.
#подготовим датасет к анализу по конкретным продуктам
abc_frame= new.groupby(['product']).agg(category=('category', pd.Series.mode),ordered=('product', 'count'), qty=('quantity', 'sum'),av_price=('price', 'mean'),income=('income', 'sum'))\
.sort_values(by='income', ascending=False).reset_index()
abc_frame.head()
| product | category | ordered | qty | av_price | income | |
|---|---|---|---|---|---|---|
| 0 | Простынь вафельная 200х180 см WELLNESS RW180-0... | бытовые товары | 2 | 30 | 1852.000000 | 53232.0 |
| 1 | Сумка-тележка 2-х колесная Gimi Argo синяя | сумки/тележки | 47 | 47 | 1072.446809 | 50405.0 |
| 2 | Тележка багажная DELTA ТБР-22 синий грузоподъе... | сумки/тележки | 3 | 59 | 616.000000 | 33992.0 |
| 3 | Набор ножей Attribute CHEF 5 предметов AKF522 | бытовые товары | 1 | 64 | 457.000000 | 29248.0 |
| 4 | Сумка-тележка хозяйственная Andersen Scala Sho... | сумки/тележки | 5 | 5 | 5609.000000 | 28045.0 |
#вызываем функцию библиотеки для проведения abc анализа. параметр True покажет статистику и границы категорий
abc=abc_analysis(abc_frame['income'], True)
#вернем резлуьтат анализа по выручке в наш исходный датасет:
a_index= abc['Aind']
b_index= abc['Bind']
c_index= abc['Cind']
cond_list = [abc_frame.index.isin(a_index),
abc_frame.index.isin(b_index),
abc_frame.index.isin(c_index)]
choice_list = ['A','B','C']
abc_frame['abc'] = np.select(cond_list, choice_list)
abc_frame.sort_values(by=['income'], ascending=False).sample(10)
| product | category | ordered | qty | av_price | income | abc | |
|---|---|---|---|---|---|---|---|
| 2212 | Стакан пластмассовый 300 мл, конус. (К-308) "П... | прочее | 2 | 2 | 22.0 | 44.0 | C |
| 1193 | Набор эмалированных салатников METALLONI Рязан... | бытовые товары | 1 | 1 | 472.0 | 472.0 | C |
| 765 | Стремянка металлическая 4 ступени, СМ4, НИКА | бытовые товары | 1 | 1 | 974.0 | 974.0 | B |
| 676 | Коврик хлопковый 40х60 см цвета в ассортименте... | бытовые товары | 5 | 12 | 97.0 | 1164.0 | B |
| 189 | Складная картонная коробка для хранения 30х20х... | прочее | 1 | 6 | 712.0 | 4272.0 | A |
| 534 | Скатерть Сидней 100% полиэфир овальная 300х150... | прочее | 1 | 1 | 1612.0 | 1612.0 | A |
| 1154 | Концентрат для мытья посуды Mama Ultimate Зеле... | прочее | 1 | 2 | 254.0 | 508.0 | C |
| 2300 | Томат Балконное чудо 0,1 г 4660010772531 | прочее | 2 | 2 | 10.0 | 20.0 | C |
| 308 | Стремянка DOGRULAR КЛАСС ПЛЮС 4 ступени 123304 | бытовые товары | 2 | 2 | 1424.0 | 2848.0 | A |
| 1478 | Котовник Блю Мун синий объем 1 л | прочее | 3 | 3 | 97.0 | 291.0 | C |
abc_frame['abc'].value_counts()
C 1527 A 560 B 248 Name: abc, dtype: int64
Граница группы A статистически обычно определяется ~ в районе 80% эффекта анализируемого критерия (поэтому анализ еще называют Парето, но границы категорий также можно задавать экспертно). Как видим из графика и распределения у нас 560 товаров группы А, 248 - B и 1527 - C. Т е всего 24% ассортимента формируют примерно 78% дохода. Главная ценность этого вывода в понимании доли группы А - она приблизительная, т к у нас данные за 13 месяцев. Если предприятие часто ротирует ассортиментную матрицу, то следует фильтровать датасет по времени, примем и это допущение.
Теперь построим heatmap по по количеству заказов в осях двух категорий. Так мы поймем сильные и слабые стороны ассортимента интернет-магазина по доходности.
#подгтовим данные в виде сводной таблицы
heat_pivot=abc_frame.pivot_table(index=(['category']), columns = 'abc',values=['ordered'],aggfunc=({'ordered' : 'sum'}))
col_names=[]
for each in heat_pivot.columns:
col_names.append(each[1])
heat_pivot.columns=col_names
display(heat_pivot)
| A | B | C | |
|---|---|---|---|
| category | |||
| бытовые товары | 390 | 165 | 278 |
| муляжи | 35 | 34 | 104 |
| прочее | 210 | 102 | 687 |
| рассада | 52 | 43 | 566 |
| растения | 732 | 149 | 675 |
| сумки/тележки | 317 | 11 | 5 |
| сушилки | 216 | 24 | 42 |
# назначим переменные для построения графика
plot = heat_pivot.reset_index().loc[0:len(heat_pivot), heat_pivot.columns[0]:].to_numpy() # внутренний участок сводной
categories=heat_pivot.index.to_list() # список категорий товаров
income_impact=heat_pivot.columns.to_list() # метки АBC
with plt.style.context("classic"):
fig, ax = plt.subplots()
im = ax.imshow(plot)
ax.set_xticks(np.arange(len(income_impact)))
ax.set_yticks(np.arange(len(categories)))
ax.set_xticklabels(income_impact)
ax.set_yticklabels(categories)
plt.setp(ax.get_xticklabels(), rotation=0, ha="right",
rotation_mode="anchor")
for i in range(len(categories)):
for j in range(len(income_impact)):
text = ax.text(j, i, plot[i, j],ha="center", va="center", color="k")
ax.set_title("Тепловой график количества заказов в категориях ABC")
fig.tight_layout()
plt.show()
Заметно, что количество заказов товара категории С избыточно - такие товары генерируют лишь 12-13% общей выручки и занимают 65% ассортимента. В первую очередь это касается ассортимента растений, почти всего из рассады, муляжей и прочих (низкооборачиваемые товары). Хотя в последнем все еще можно отыскать полезные для покупателей и доходные для бизнеса позиции группы A. Ассортимент бытовых товаров также изобилует группой С. Лучше всего дела обстоят для сушилок и сумок-тележек. Возможно руководству следует углубиться в анализ и сделать зачистку ассортимента в бытовых товарах, также подумать над логистикой и затратами для муляжей, рассады, отказаться от части растений если это поможет структурно оздоровить PnL компании.
Изучим динамику продаж во времени в разрезе категорий, чтобы определить тренды и дополнить рекомендации. Возьмем весь доступный период 13 месяцев - так сможем сравнить продажи октября год к году.
#забираем категории ABC в наш рабочий датафрейм
new=pd.merge(
new,
abc_frame[['product','abc']],
how="left",
on='product',
sort=False,
copy=False,
indicator=False,
validate=None,
)
# рисуем normolized stacked area chart чтобы показать динамику категорий
fig = go.Figure()
#задаем цвета для будущих областей графика, в зависимости от количества категорий
N = len(new.category.unique())
HSV_tuples = [(x*1.0/N, 0.75, 0.75) for x in range(N)]
RGB_tuples = list(map(lambda x: colorsys.hsv_to_rgb(*x), HSV_tuples))
#строим слои графика через добавление объектов go в Plotly
for cat, j in zip(new.category.unique(), range(0,len(new.category.unique()))):
stacked_sales=new.query('category==@cat').groupby(['simple_date']).agg(quantity=('quantity', 'sum')).reset_index()
fig.add_trace(go.Scatter(
x=stacked_sales.simple_date.tolist(),
y=stacked_sales.quantity.values.tolist(),
mode='lines',
line=dict(width=0.5, color=f'rgb{RGB_tuples[j]}'),
name=f'{cat}',
stackgroup='one',
groupnorm='percent' # sets the normalization for the sum of the stackgroup
))
fig.update_layout(
showlegend=True,
title= 'Динамика продаж по долям категорий в %',
xaxis_type='date',
yaxis=dict(
type='linear',
range=[1, 100],
ticksuffix='%'))
fig.show()
# рисуем аналогичный график для абсолютных значений продаж в шт и дохода
fig = make_subplots(rows=2, cols=1)
#строим слои графика через добавление объектов go в Plotly
for cat, j in zip(new.category.unique(), range(0,len(new.category.unique()))):
stacked_sales=new.query('category==@cat').groupby(['simple_date']).agg(quantity=('quantity', 'sum'),income=('income', 'sum')).reset_index()
fig.add_trace(go.Scatter(
x=stacked_sales.simple_date.tolist(),
y=stacked_sales.quantity.values.tolist(),
hoverinfo='x+y',
mode='lines',
line=dict(width=0.5, color=f'rgb{RGB_tuples[j]}'),
name=f'{cat}, шт',
stackgroup='one', legendgroup=False
), row=1, col=1)
fig.add_trace(go.Scatter(
x=stacked_sales.simple_date.tolist(),
y=stacked_sales.income.values.tolist(),
hoverinfo='x+y',
mode='lines',
line=dict(width=0.5, color=f'rgb{RGB_tuples[j]}'),
name=f'{cat}, доход',
stackgroup='one', legendgroup=True
), row=2, col=1)
fig.update_layout(
showlegend=True,
legend_groupclick="toggleitem",
legend_tracegroupgap=70,
title_text= 'Cовместная динамика продаж в шт и совокупного дохода в разбивке категорий',
xaxis_type='date')
fig.show()
На графиках выше заметно, что существуют очевидная сезонность по году для рассады (апрель/май 27-33 % продаж, не выше 11% в остальной период), пиковый интерес к растениям ( в марте ~ 44% всех проданых товаров, в июне 37%). Спрос на сумки тележки и сушилки стабильно невысокий, но приносящий относительно крупный доход. Он частично компенсирует общее падение доходов (-33% к месяцу прошлого года) на фоне совокупного снижения продаж в количестве (-38% к месяцу прошлого года). По большей части это происходит из-за доли бытовой техники - за год она сократилась более чем в два раза в продажах и выручке. В октябре падение доходов здесь замедлилось - возможно это большая доля ассортимента A или просто рост цен, т к продажи продолжают сокращаться. Конечно для бытовых товаров характерна сезонность зимой перед новым годом, однако тренд на годовом фрейме пока выглядит плохо. У растений также не вполне здоровая динамика - продажи в штуках растут месяц к месяцу прошлого года на 29%, но доход меньше на 15%. Еще один повод заняться категорией также в первую очередь.
В целом по совокупности выводов следует, что магазину следует резать издержки, сокращая неприносящий доход ассортимент. Сэкономленные деньги можно запускать на усиление категорий растения, мероприятия нацеленные на улучшение unit экономики в каждой категории.
Исходя из особенностей динамики продаж, будет важным обнаружить статистическую значимость в изменении доходности главных продуктов. На данный момент в приоритете остановить падение категории бытовых товаров. Поэтому дополнительно изучим, насколько сильно сокращение категории повлияло на доходность товаров группы А по году. Так можно будет уточнить природу нисходящего тренда продаж бытовых товаров и сформировать дальнейшую продуктовую стратегию.
H_0: Выручка (income) от покупок (заказов) бытовых товаров категории А за 11.2018-04.2019 = Выручка (income) от покупок (заказов) бытовых товаров категории А за 05.2019-10.2019`
H_1: Выручка (income) от покупок (заказов) бытовых товаров категории А за 11.2018-04.2019 ≠ Выручка (income) от покупок (заказов) бытовых товаров категории А за 05.2019-10.2019`
alpha = 0.05
Разделим 13 месяцев на полугодия относительно даты 31.04.2019 (октябрь 2018 не берем в тест, чтобы использовать именно годовой срез), будем сравнивать два полугодия до 31.04.2019 и после. Для этого понадобится столбец с признаком H1 - (<2019-04-31), H2(>2019-04-31). Применим простую функцию к столбцу даты ниже и в этой разбивке также построим график распределения доходностей.
def halfing_year(row):
date = row['simple_date']
if date <="2019-04-31":
return 'H1'
return 'H2'
# применяем функцию чтобы выделить первое и второе полугодие в датасете относительно даты 2019-04-31:
new['half_year'] = new.apply(halfing_year, axis=1)
ax=sns.violinplot(x ='half_year',
y ='income',
data = new.query('simple_date > "2018-10-31" & category=="бытовые товары" & abc=="A"'),
linewidth = 6);
ax.set(ylim=(-2000,15000))
ax.set_title('Форма распределения доходности заказов бытовых товаров категории А')
ax.set(xlabel='Полугодие до и после 31 апреля 2019 года', ylabel='Средняя выручка заказа');
Из визуализации распределений напрашивается вывод что средняя выручка от продажи бытовой техникии категории А скорее не изменилась. Однако произошли неизвестные изменения влияющие на форму распределения в H2 - оно уже, и имеет более заметное смещение наверх по уровню доходности, это может быть связано с количеством в заказов выборках или заметным изменением характеристик объема или цены к прошлому полугодию. Проведем тест чтобы подтвердить или опровергнуть гипотезу. Для проверки гипотезы о равенстве средних двух генеральных совокупностей для зависимых (парных) выборок в Python, применим метод scipy.stats.ttest_rel(). Т к данные однородны, будем считать что дисперсия одинакова, оставим одинаковое количество наблюдений через рандомную выборку для одного из срезов.
alpha = 0.05 # уровень статистической значимости
#для теста важно иметь одинаковое количество в выборке. sample(len(after)) не нарушает релевантность выводов, т к распредление выручки нормальное
after=new.query('half_year == "H2" & category=="бытовые товары" & abc=="A"')['income']
before=new.query('half_year == "H1" & category=="бытовые товары" & abc=="A"')['income'].sample(len(after))
results = st.ttest_rel(before,after)
print('p-значение:', results.pvalue)
if results.pvalue < alpha:
print('Отвергаем нулевую гипотезу')
else:
print('Не получилось отвергнуть нулевую гипотезу')
p-значение: 0.344925698807359 Не получилось отвергнуть нулевую гипотезу
Данные не предоставляют достаточного основания отвергнуть нулевую гипотезу: p-value сильно превышает уровень значимости что в 5%, что даже в 10%). Значит, можно сделать вывод, что доходность (средняя выручка) одного заказа бытовой техникии категории А не изменилась несмотря на двойное дохода общей категории. Это исключает фактор цены для товара категории A, значит изменился спрос, возможно какие-то модели успели технически устареть? Напоследок можно проверить пересечение множества наименований товаров H1 и H2 из теста. Так можно вынести конкретные изменения в матрице для предметного обсуждения с руководством интернет магазина.
Кстати, при многочисленном запуске кода выше происходит случайная выборка 135 продаж из 255 для H1 (т к их было больше, а H2 = 135) — гораздо чаще мы получаем тот же результат: p-value сильно превышает пороги. Это подтверждает нормальность распределения данных в H1. При этом конечно увеличиваются риск ошибок первого и второго рода, но для наших размеров выборок можно считать результаты репрезентативными.
demand_risk_products=Counter(new.query('half_year == "H1" & category=="бытовые товары" & abc=="A"')['object'].unique()) - Counter(new.query('half_year == "H2" & category=="бытовые товары" & abc=="A"')['object'].unique())
demand_risk_products
Counter({'набор вешалок': 1,
'веник сорго': 1,
'набор ножей': 1,
'простынь': 1,
'наматрацник': 1})
Количество SKU, выпавших из спроса бытовых товаров категории А в H2 SKU - 118. Чтобы не засорять эфир Jupiter, наверху выведены лишь объекты наименований. Получается, что одной из причин снижения выручки по категории бытовых товаров класса А являются исчезновение из спроса (или предложения) вешалок, веников, наборов ножей, простыней и наматрацников.
Итак, имеющийся набор данных позволяет ознакомится с ассортиментом интеренет магазина, подчеркнуть закономерности в динамике продаж, включая количественные характеристики. В ходе работы была задана уникальная категоризация по товарам, определены самые сильные продукты приносящие большую долю выручки. Тем самым в условиях падения продаж можно концентрировать усилия на важных для бизнеса участках. Однако полный вывод и рекомендацию об управлении ассортиментом следует делать на основе комплексной оценки вместе с прогнозом доходности, учетом затрат и обогощенной информации по клиентам и т д.
Общая рекомендация на основе анализа доходной части и динамики категорий - сконцентрироваться на выявлении сезонного товара в категории бытовых товаров, выводить из всего ассортимента непопулярные товары категорий B и С, сформулировать стратегию развития категорий растения. Также нужно уделить внимание развитию нишевых категорий сушилок и сумок/тележек, провести в них анализ клиентов. Решить необходимость заниматься муляжами и рассадой - товары многочисленны но не приносят сопостовимого дохода.
Самая крупная по доле выручки - "бытовые товары", самая многочисленная по заказам - "растения". Достаточно большие и явно выделяющиеся от остальных - "сумки/тележки", "сушилки", генерирующие стабильный доход. Самые маленькие по выручке, но также с большим количеством товара - "рассада" и "муляжи". В категорию "прочие" попали все редкие по продажам товары с долей в совокупной выручке < 17%.
При дальнейшем анализе важно определить область регулярного бизнеса. Важно обратить внимание на первичный состав данных - в нем содержатся скрытые дубликаты и очень редкие аномальные заказы. Так, был определен возможный источник дубликатов, а аномалии изначально изолированы с отдельной рекомендацией. Удаление аномальных товаров с большим количеством позволило получить значимые статистические выводы по регулярным заказам. Из-за высокого порога для отсечения удалось точно определить, что это не BAU заказы, их следует выносить на отдельное рассмотрение - шансы на получение таких заказов ~ 0.28%. Рекомендация - рассмотреть их адресно, и определить можно ли выделить такие товары или покупателей в раздел оптовой торговли с бизнесом/предпринимателями и уже там категоризировать.
Подводя итоги анализа можно разделить ассортимент в категориях "основной" и "дополнительный". К основным отнесем только крупные и перспективные категории по выручке(бытовые товары, сумки/тележки, растения, сушилки), из групп А, B. Все остальное попадет в дополнительный ассортимент - финально взглянем на характеристики в такой разбивке для подтверждение корректности и соблюдения Парето эффективности.
# функция для деления ассортимента на основной и дополнительный
def typing_cat(row):
category = row['category']
group = row['abc']
if (group == 'A' or group == 'B') and category in 'бытовые товары сумки/тележки растения сушилки':
return 'основной'
return 'дополнительный'
# применяем функцию чтобы выделить основной и дополнительный ассортимент:
new['type'] = new.apply(typing_cat, axis=1)
final_summary = new.groupby(['type']).agg(count=('product', 'count'), qty=('quantity', 'sum'),income=('income', 'sum'))\
.sort_values(by='income', ascending=False)
final_summary['income_cumulated'] = final_summary['income'].cumsum()
final_summary['share_of tincome'] = round(final_summary['income_cumulated']/final_summary.income.sum(),2)
display(final_summary)
| count | qty | income | income_cumulated | share_of tincome | |
|---|---|---|---|---|---|
| type | |||||
| основной | 2004 | 4409 | 2377374.0 | 2377374.0 | 0.71 |
| дополнительный | 2833 | 6258 | 966720.0 | 3344094.0 | 1.00 |
#сохраним датафрейм локально для загрузки в Tableau
new.to_csv(r'C:\Users\kslvd\pyn_projects\yandex_projects\product_analysis\tableau_data.csv')
Презентация заказчику pdf - https://disk.yandex.ru/i/U5eRylyLihrH0A
Публичный дашборд в Tableau - https://public.tableau.com/views/product_analysis_project/Dashboard1?:language=en-US&:display_count=n&:origin=viz_share_link